네 줄 요약

  1. prod CPU profile에서 Feature Flag의 YAML parser가 전체 CPU의 약 4%를 차지
  2. Redis retriever가 생성한 JSON을 YAML로 파싱하는 병목 지점 파악 후 올바른 파서가 동작하도록 조치
  3. 배포 후 prod 6개 pod 표본에서는 YAML 함수가 검출되지 않았고 JSON 변환 경로는 약 0.003%로 확인
  4. 위 문제를 일반화한 FormatHinter를 추가하여 go-feature-flag 오픈소스 기여

profile에 나타난 YAML 함수

사내 Pyroscope로 메인 서버 성능을 파악하던 중 규모가 크진 않지만 병목을 차지하는 함수(gopkg.in/yaml.yaml_parser_state_machine)를 발견했다. 당시 profiling 기록에는 해당 함수가 전체 CPU의 약 4%, ~630M CPU cycles를 먹고 있었다. yaml의 사용처는 k8s 설정 파일 외에는 당장 떠오르는 사용처가 없었고, 그렇기 때문에 전체 cpu clock의 4%나 차지하는 게 의심스러웠다. 해당 함수의 call stack을 타고 올라보니 go-feature-flag(이하 ff) 라이브러리의 ConvertToFlagStruct()에서 호출되고 있었다. (ff는 피쳐플래그 라이브러리로 플래그 데이터를 redis에 저장하고 주기적으로 polling 하는 식으로 동작한다)
쉽게 말해 redis에 저장한 플래그 데이터를 구조체로 전환하는 함수로, JSON 데이터를 Go struct로 unmarshal 하는 단순 컨버트 함수였다. 그런데 여기서 웬 YAML이 나오는 걸까? ff 설정 어디에도 YAML을 사용할 이유는 없어 보였다. 원인을 더 명확히 파악하기 위해 ff의 retriever가 무엇인지 간단히 짚고 넘어가자.

Redis부터 YAML parser까지 호출 경로를 따라가기

ff의 retriever는 feature flag 설정을 외부 저장소에서 읽어 오는 어댑터를 의미한다. 우리 회사처럼 Redis에서 flag를 조회하면 Redis retriever, 로컬 파일을 읽을 땐 (File retriever), HTTP 응답 사용 시 (HTTP retriever) 등 다양한 종류의 retriever가 존재한다. retriever는 외부 저장소에서 Feature Flag 데이터를 읽어 []byte로 반환하고, cache manager는 반환된 byte 데이터를 역직렬화해 Feature Flag 구조체로 변환한다.

text
외부 저장소(redis, Mongo, Postgres etc)
Retriever
  ↓ []byte
파서(JSON/YAML/TOML)
Feature Flag 구조체
Flag evaluation

문제는 이 값을 로컬 캐시의 Feature Flag 구조체로 바꾸는 다음 단계에 있었다. 당시 cache manager는 retriever 종류를 따지지 않고 전역 Config.FileFormat을 parser에 전달했다. FileFormat이 비어 있으면 default 분기의 yaml.Unmarshal을 호출했다.

ff-ConvertToFlagStruct

인자로 fileFormat을 받도록 되어 있으니 호출하는 곳에서 적절한 fileFormat만 넘겨주면 된다고 생각할 수 있지만, 이를 강제하지 않았고 default가 일종의 fallback 동작을 하여 문제가 없었다. 즉 fileFormat 값을 명시적으로 넘기지 않으면 retriever가 JSON을 만들어도 YAML parser가 동작한다. 그럼 여기서 어떻게 JSON이 YAML Parser에서 동작하는지 의문이 생길 수 있다.

JSON이 YAML 파서에서 동작한 이유

YAML 1.2는 JSON의 strict superset이다. YAML 1.2 문법을 기준으로 유효한 JSON 문서는 YAML 문서로도 해석할 수 있다. YAML 1.2 스펙에도 이를 주요 목표로 명시하고 있다.

일반적인 JSON object와 array 표기는 YAML의 flow collection과 호환된다. 따라서 아래 JSON은 YAML 파서에서도 정상적으로 파싱되지만 YAML의 flow style 전체가 JSON 문법과 같다는 의미는 아니다.

yaml
# JSON 문법(YAML flow collection과 호환)
{
  "name": "buzzvil",
  "tags": [
    "ad",
    "reward"
  ]
}

# Block style (YAML 고유)
name: buzzvil
tags:
  - ad
  - reward

즉, 유효한 JSON은 YAML 파서가 읽을 수 있지만 반대는 성립하지 않는다. YAML의 flow style에는 JSON에 없는 표현도 있으며 block style과 앵커, 주석 등 YAML 고유 문법은 JSON 파서가 읽을 수 없다.

YAML 파싱 경로에서 CPU 비용이 발생한 이유

JSON이 YAML 파서에서도 정상적으로 파싱된다면, ConvertToFlagStruct()이 CPU 프로파일의 핫스팟으로 나타났을까?

JSON은 object, array, string, number처럼 비교적 제한된 문법을 처리한다. 반면 YAML은 flow와 block style, 들여쓰기, anchor, alias, tag, 여러 scalar 표현까지 고려해야 한다.

구분encoding/jsongo-yaml/v3
지원 문법JSONJSON을 포함한 YAML 문법
처리 구조scanner와 decoder가 Go 값으로 변환scanner와 parser가 yaml.Node를 만들고 decoder가 Go 값으로 변환
이 사례Redis retriever 출력의 의도한 경로FileFormat이 비었을 때 선택된 fallback 경로
text
[YAML 경로] JSON bytes → scanner/parser → yaml.Node → decoder → Go 구조체
[JSON 경로] JSON bytes → scanner/decoder → Go 구조체

encoding/json에도 상태 머신 기반 scanner가 있으므로 상태 머신 사용 여부만으로 비용 차이를 설명할 수는 없다. go-yaml/v3가 지원하는 더 넓은 문법과 yaml.Node를 거치는 처리 경로는 추가 작업을 만들 수 있다.

정확한 성능 차이는 데이터와 구현에 따라 달라지므로 구조만 보고 2~3배라고 일반화하기 어렵다. 이 사례에서는 프로덕션 프로파일에서 yaml_parser_state_machine이 약 4%를 차지한 것을 근거로 불필요한 YAML 경로의 비용을 확인했다.

개선 결과 확인

회사 코드 수정은 간단했다. 앞서 언급한 대로 그저 file format을 명시적으로 선언하여 yaml 파서가 아닌 json 파서가 처리하도록 수정했다.

ff-bsapi.png

diff
ffConfig := ffclient.Config{
    PollingInterval: 60 * time.Second,
+   FileFormat:      "json",
    Retriever:       &redisretriever.Retriever{...},
}

이제 Redis retriever가 만든 JSON bytes는 json.Unmarshal 분기를 탄다.

bash
Redis Retriever
    │ SCAN + GET
각 값 json.Unmarshal, 전체 map json.Marshal
JSON bytes
    │ FileFormat: "json"
json.Unmarshal
Feature Flag DTO

이후에 설명하겠지만 FileFormat을 명시적으로 설정하여 cpu 병목 지점이 Pyroscope에서 사라진 것까지 확인했다. 표본의 전체 CPU sample은 2,831.47초로 YAML 관련 함수는 검출되지 않았고, ConvertToFlagStruct → encoding/json.Unmarshal의 누적 CPU는 0.08초였다. 전체 비율로 계산하면 0.0028% 정도로 이전의 4%와 비교하면 확연히 개선된 점을 확인할 수 있었다. (다만 서비스 CPU가 정확히 4% 줄었다고 하기엔 당시의 트래픽, workload, pod 범위가 다르기 때문에 cpu 성능 개선이 4% 나아졌다고 단정지을 수는 없었다.)
불필요한 yaml 파싱으로 인한 병목을 적절한 parser 호출로 개선한 간단한 작업이었다.

구분변경 전 prod 기록변경 후 prod 표본
범위당시 profile, raw 원본 미보존30분, 6개 pod
parser 경로YAML parser 약 4%YAML 함수 미검출
대응 JSON 변환 경로확인 불가누적 0.08초 / 0.0028%

오픈소스 기여로 근본부터 수정하기

회사 코드는 단순히 FileFormat: "json" 추가만으로 충분했지만, go-feature-flag 라이브러리 자체에서 default로 yaml parser를 강제하는 것이 마음에 안 들었다 신경 쓰였다. 애초에 retriever에 맞는 parser를 세팅해주면 안 되나?

앞서 언급했듯 ff는 Redis 외에 File, HTTP, MongoDB, PostgreSQL 같은 retriever도 지원한다. Redis, MongoDB, PostgreSQL retriever는 결과를 JSON으로 직렬화하지만 File과 HTTP는 사용자가 제공한 원본 bytes를 그대로 전달하기에 JSON parser로 강제할 수도 없다.

Retriever반환 포맷전역 JSON 강제
Redis / MongoDB / PostgreSQL구현상 JSON가능
File / HTTP 등사용자 원본 포맷불가능

cache manager가 Redis 같은 구체 타입을 검사하게 만드는 방법도 있지만 retriever가 추가될 때마다 도메인 코드에 구현 세부사항이 매번 추가되는 점이 문제라 생각했다. 기존 Retriever 인터페이스에 포맷 메서드를 추가하는 방법은 사용자가 직접 만든 모든 retriever가 새 메서드를 구현해야 하므로 하위 호환성을 깨뜨린다.

go
// cache manager 내부에서 구체 타입을 검사하는 방법, 새로운 retriever가 추가될 때마다 수정이 필요하여 좋은 방법이 아니라고 생각했다.
func getOutputFormat(r Retriever, defaultFormat string) string {
    switch r.(type) {
    case *redisretriever.Retriever,
        *mongodbretriever.Retriever,
        *postgresqlretriever.Retriever:
        return "json"
    default:
        return defaultFormat
    }
}

retriever가 출력 포맷을 알려주도록

아예 FormatHinter라는 새로운 인터페이스를 만들고 OutputFormat()이라는 인터페이스 메서드를 내부에 추가했다. (Go에서도 추상 메서드라는 말을 쓰던가?) 이렇게 하면 각 retriever manager에서 해당 인터페이스를 구현했는지 type assertion으로 확인할 수 있어 RedisRetriever 같은 구체 타입이 아닌 해당 retriever의 출력 포맷만 확인할 수 있다. 새로운 인터페이스는 옵셔널하게 구현을 선택할 수 있어 하위 호환을 깨뜨리지도 않고, getOutputFormat() 함수 내부에서 (Redis 같은) 구체 타입을 검사하지도 않기 때문에 결합도를 높이지도 않는다. 위 내용에 기반하여 새로운 인터페이스를 도입하는 이슈를 올렸다. go-feature-flag Issue #5321

go-feature-flag FormatHinter PR

go
type FormatHinter interface {
    OutputFormat() string
}

cache manager는 retriever가 FormatHinter를 구현했는지 확인한다. 반환값이 있으면 hint를 먼저 사용하고 구현하지 않았거나 빈 값을 반환하면 기존의 전역 FileFormat으로 돌아간다.

ff-code.png

위 내용처럼 PR을 올렸고 메인테이너의 승인을 받아 병합됐다. PR #5322
참고로 이분은 혼자 오픈소스를 유지보수하시는 것 같다. 기여할 때마다 항상 반갑게(?) 기여를 맞이해주신다.

go-feature-flag maintainer approval

마무리

정상적으로 동작하는 코드라도 profile을 까보면 예기치 못한 비용과 동작을 드러낸다. 기능상 에러는 없었지만 불필요하게 성능을 잡아먹는 이슈가 있었고 이를 관측할 수 있는 환경이 구축된 덕분에 문제 파악부터 해결까지 연결될 수 있었다.
사내 코드는 한 줄의 변경으로 개선할 수 있었으나 오픈소스 라이브러리는 특정 포맷을 강제할 수 없으니 결국 retriever가 출력 포맷을 알리도록 책임을 옮겼다. 기존 인터페이스를 깨뜨리지 않으면서도 불필요한 YAML parsing을 피할 수 있었다.

프로덕션에서 누락된 한 줄의 설정이 데이터 포맷의 책임을 어디에 둘 것인가라는 설계 문제로 이어졌고 적절한 해법을 찾기 위해 여러 방안을 고민할 수 있는 작업이었다.

Reference